跳转至

7 Complex Custom Animations

By now, you can see that creating more complex animations in SwiftUI relies on understanding how the SwiftUI protocols and animation engine work. Done correctly, your custom animations still use SwiftUI to handle as much work as possible.

To create more complex animations, you often need to combine several elements working together. One way to produce a more complex animation is to combine view transitions with animated state changes. Animating the appearance and removal of a view while animating a state change can make a view stand out and clarify the relationship between new elements on a view.

In the previous chapter, you worked on adding animations to your custom views. Up to this point, your animations were limited to relying on a single property, but SwiftUI also supports animating multiple property changes within the same view. In this chapter, you’ll create a view that supports five independently animated values.

First, you’ll look at how to combine transitions and animations to produce a unified animation.

Adding a Popup Button

Open the starter project for this chapter. You’ll see the tea brewing app you worked with in the previous chapter with a few added features. Since tastes in tea can vary, the app now lets users customize the brew settings. They can also record their review of the results of each brew to help them find the perfect process to match their taste for each tea.

Open TimerView.swift. You’ll see the timer is now at the top of the view to make it easier to see. The timer also adds a slider to let the user adjust the brewing length.

Further down, you’ll see the familiar information showing the suggested brewing temperature and a slider that lets the user adjust the amount of water so the app can provide a suggested amount of tea. You’ll now add a button so the user can adjust the suggested ratio of tea to water.

Create a new SwiftUI view file inside the Timer folder named PopupSelectionButton.swift. Add the following properties to the generated view:

@Binding var currentValue: Double?
var values: [Double]

These properties provide a binding that passes the selection back from the view. It also allows passing in an array of Double values that can be selected. Replace the preview body with:

PopupSelectionButton(
  currentValue: .constant(3),
  values: [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0]
)

This code provides the view sample settings. Update the view’s body to:

Group {
  if let currentValue {
    Text(currentValue, format: .number)
      .modifier(CircledTextToggle(backgroundColor: Color("Bourbon")))
  } else {
    Text("\(Image(systemName: "exclamationmark"))")
      .modifier(CircledTextToggle(backgroundColor: Color(.red)))
  }
}

This code attempts to unwrap the currentValue binding property. If successful, the value will display using the color bourbon for the background. If not, the view will show an exclamation mark with a red background. You wrap the conditional inside a Group so you can apply additional modifiers to the two view states without repeating code. The CircledTextToggle view modifier is identical to the CircledText view modifier, except it applies a fixed frame to the Text. Without adding this frame, the changing size of the Text view when transitioning from text to a system image would cause the view to shift.

Since you provided the preview a value of 3, you’ll now see the result, which shows the numeral three with the bourbon color background.

img

Your button shows the value but doesn’t let the user change it. You’ll implement that in the next section.

Adding Button Options

Add the following property after values:

@State private var showOptions = false

This state property stores whether the view should show the options. To toggle it, add the following modifier to Group:

.onTapGesture {
  showOptions.toggle()
}

When the user taps the view, you toggle showOptions. Now you need to show the user the options. You’ll lay out the options in an arc starting above the button. Add the following methods after the body:

private func xOffset(for index: Int) -> Double {
  // 1
  let distance = 180.0
  // 2
  let angle = Angle(degrees: Double(90 + 15 * index)).radians
  // 3
  return distance * sin(angle) - distance
}

private func yOffset(for index: Int) -> Double {
  let distance = 180.0
  let angle = Angle(degrees: Double(90 + 15 * index)).radians
  return distance * cos(angle) - 45
}

Here’s how these two methods create the arc layout:

  1. You set distance to 180 for the radius of a circle. You’ll lay the buttons along this circle.
  2. You want each button rotated 15 degrees from the previous one, so you multiply the index by 15. You then add 90 to this value which rotates the element’s location a quarter turn counter-clockwise. Note this difference from SwiftUI rotations. In a SwiftUI rotation, an increase in the angle rotates further clockwise. You then convert the angle from degrees to radians.
  3. Then you multiply the distance by the sine of the angle. The Swift sinfunction expects the angle in radians, which you converted to in the previous step. You then subtract the distance from this value which shifts the circle’s center to the left. As a result, the x offsets start in line with the button and then decrease as the angle increases.

The vertical offset calculation works the same, except you use a cosine since you’re dealing with the y value. You subtract 45 to shift the circle’s center to just above the button. With those methods to calculate each view’s position, you can now show the options in the view.

Wrap the current Group inside a ZStack by holding down Command and clicking the Group view. Then select Embed in ZStack from the menu. A ZStack overlays its views, with each view lying above the previous views in the stack. Since you want to overlay these options, this is perfect.

Now add the following code to the start of the Group:

// 1
if showOptions {
  // 2
  ForEach(values.indices, id: \.self) { index in
    // 3
    Text(values[index], format: .number)
      .modifier(CircledText(backgroundColor: Color("OliveGreen")))
      // 4
      .offset(
        x: xOffset(for: index),
        y: yOffset(for: index)
      )
      // 5
      .onTapGesture {
        currentValue = values[index]
        showOptions = false
      }
  }
  Text("\(Image(systemName: "xmark.circle"))")
    .transition(.opacity.animation(.linear(duration: 0.25)))
    .modifier(CircledTextToggle(backgroundColor: Color(.red)))
}

Here’s what this code does:

  1. You’ll only show the options when showOptions is true.
  2. You iterate through the indices property of the values array to get the index of each element in the array.
  3. Then, you show the value using the initializer Text that allows passing a format. Using the number format also displays the value concisely with only the minimum digits needed to reflect the value.
  4. You offset each option vertically using the methods you just added to the view.
  5. When the user taps one of the options, you set the currentValue binding to the value and then set showOptions to false to hide the options.

You now have an implementation you can use in your app. Open BrewInfoView.swift, which contains the view showing the suggested amount of tea for a given amount of water. Find the last Text element in the VStack and replace it with the following:

HStack(alignment: .bottom) {
  Text("\(teaToUse.formatted()) teaspoons")
    .modifier(InformationText())
  Spacer()
  PopupSelectionButton(
    currentValue: $waterTeaRatio,
    values: [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0]
  )
}

You added the new PopupSelectionButton view to let the user select the desired ratio and provide several options between 1.0 and 5.0.

Now run the app. Select any tea, tap the button and change the ratio. Observe how the suggested amount of tea changes to match the new ratio. Adjust the amount of water and observe that the tea adjusts to fit.

img

While the buttons show, they appear suddenly. In the next section, you’ll animate the appearance of the options.

Animating the Options

Since an animation requires a state change, your first thought might be to animate using the showOptions already in place. If you try that, you’ll find a problem. Changing showOptions causes SwiftUI to add or remove views. If you recall, you need a special type of animation called a transition to animate the appearance or removal of views.

You might consider triggering the animation based on the same state change, but that can be complicated. Instead, you’ll introduce a new property to manage the animation.

Back in PopupSelectionButton, add the following new property after the existing ones:

@State private var animateOptions = false

You’ll use this property to manage the view appearance and removal independently of each other. Now update your offset(x:y:), under comment four, to:

.offset(
  x: animateOptions ? xOffset(for: index) : 0,
  y: animateOptions ? yOffset(for: index) : 0
)

Now you only offset the views when animateOptions is true. Otherwise, they would remain hidden under the main button since they appear earlier in the ZStack. Changing animateOptions animates the buttons so they appear behind the main button and move to their final positions.

Next, update the code inside the outer onTapGesture attached to the Group to:

// 1
withAnimation(.easeOut(duration: 0.25)) {
  animateOptions = !showOptions
}
// 2
withAnimation { showOptions.toggle() }

You’ll run two animations separately based on the current value of showOptions.

Here’s how the code manages these changes:

  1. You wrap the change of animateOptions inside withAnimation(_:_:) using the ease-out animation with a duration of 0.25 seconds. You set animationOptions to the opposite of showOptions — if showOptions is currently true, it means you need to hide the options, and vice-versa.
  2. You use a separate withAnimation(_:_:) to toggle showOptions. You do not specify an animation since this will trigger a transition applied to the view.

Recall that you also hide the options when the user selects an option. You need to update that code to match these changes. Replace the onTapGesture closure inside the ForEach loop with:

.onTapGesture {
  currentValue = values[index]
  withAnimation(.easeOut(duration: 0.25)) {
    animateOptions = false
  }
  withAnimation { showOptions = false }
}

You’re using the same code you did earlier, except you know you’re hiding the options. As the last step, you’ll add a transition to the view.

Go to the Text view at the top of the ForEach loop. Add the following modifier after the view and before all the other modifiers:

.transition(.scale.animation(.easeOut(duration: 0.25)))

By default, the scale animation scales from and to a vanishing point at the center of the view. You apply an ease-out animation with a duration of 0.25 seconds to the transition, which matches the animation used with the change of offset position. Using the same animation keeps the two in sync, so they act as a single combined animation instead of separate animations.

Run the app, select a tea and tap the button.

Now you’ll see the options slide out from under the original button.

img

You’ve created an animated popup button combining transitions and state animation. As with most animations, it only animates a single element, the position. In the next section, you’ll learn about animating a view with multiple properties.

Animating Multiple Properties

In Chapter 6: Intro to Custom Animations, you learned about the Animatable protocol and used it to produce views that could handle animations beyond what SwiftUI can handle by default. The changing number and sliding number animations you built in those chapters only dealt with a single changing value. In this section, you’ll create a view with five parameters that are fully animated.

The app shows you the past ratings of your brews. While the list shows the information, it would be nice to provide a visualization to help clarify the relationships between the different settings and the results. To do this, you’ll create a radar chart: a visualization to compare the characteristics of multiple values by plotting the data as a polygon, with each corner of the polygon representing one value. A radar chart looks like this:

img

Run the app and select Green Tea or Oolong Tea, which already have ratings. At the bottom of the view, you’ll see the ratings listed. Tap anywhere in that window, and you’ll see a sheet showing the first rating. You can quickly swipe between the ratings.

img

You’ll now create a visualization that reflects the values in these ratings.

Creating a Radar Chart

Create a new SwiftUI view file named AnimatedRadarChart.swift under the RadarChartgroup. Add the following properties to the new view:

var time: Double
var temperature: Double
var amountWater: Double
var amountTea: Double
var rating: Double

These are the five properties that your radar chart will show. For greater precision during the animation, you use a Double type for each. Now update the preview to provide the values. Change the body of the preview to:

AnimatedRadarChart(
  time: Double(BrewResult.sampleResult.time),
  temperature: Double(BrewResult.sampleResult.temperature),
  amountWater: BrewResult.sampleResult.amountWater,
  amountTea: BrewResult.sampleResult.amountTea,
  rating: Double(BrewResult.sampleResult.rating)
)

Now add the following computed property to the view:

var values: [Double] {
  [
    time / 600.0,
    temperature / 212.0,
    amountWater / 16.0,
    amountTea / 16.0,
    rating / 5.0
  ]
}

This computed property takes the individual values, turns them into an array you can loop over and handles the problem of scaling the chart by dividing each value by the maximum expected value. This step turns each value into a fraction between zero and one and ensures that charts from different measurements are comparable.

Now you’ll work on the chart to show these values. Replace the body of the view with:

// 1
ZStack {
  // 2
  GeometryReader { proxy in
    // 3
    let graphSize = min(proxy.size.width, proxy.size.height) / 2.0
    let xCenter = proxy.size.width / 2.0
    let yCenter = proxy.size.height / 2.0
  }
}

This code makes some calculations you need to match the size of the chart to the size of the view. Here’s what it does:

  1. You’ll add more to this chart later in this chapter, so you build the view within a ZStack, which overlays child views.
  2. Using a GeometryReader causes the views in the closure to take up as much space as possible and allows you access to information about the view’s size. You’ll use this information to scale the chart within the view.
  3. You can calculate some values you’ll use later from the GeometryProxy passed to the closure of the GeometryReader. You determine which is smaller: the view’s vertical or horizontal size. Then you divide it by two to determine the number of points to display when a value is at the maximum value. To help center the chart within the view, you calculate the center points in each position by dividing the width and height by two.

Now add the following code to the end of the GeometryReader:

// 4
ForEach(0..<5 id: \.self) { index in
  Path { path in
    path.move(to: .zero)
    path.addLine(to: .init(x: 0, y: -graphSize * values[index]))
  }
  // 5
  .stroke(.black, lineWidth: 2)
  // 6
  .offset(x: xCenter, y: yCenter)
  // 7
  .rotationEffect(.degrees(72.0 * Double(index)))
}

Here’s what the code does:

  1. You loop between 0 and 4, since values has 5 elements. Remember, valuescontains a scaled value between zero and one for each item to show on the chart. You then create a path that begins at the zero point and adds a line from that point. In SwiftUI, a negative value indicates a position upward in the view. To create a vertical upward line, you multiply the negative of the graphSize value computed earlier by the fraction of the current point.
  2. You draw the path on the view using stroke(_:lineWidth:), which draws a black line of width two.
  3. The origin of a drawing in a SwiftUI view is at the leading top corner by default. To shift this to the center of the view, you apply offset(x:y:), passing the center locations you calculated in step three.
  4. You want to produce five equally spaced lines around the center point. You divide the 360 degrees of a full circle by five to find that you should rotate each line 72 degrees from the previous one. Since an increased number rotates clockwise in SwiftUI, succeeding lines will appear clockwise from the first.

You’ll see the plotted values in the preview.

img

With the basics in place, you’ll fill out the rest of the chart in the next section.

Adding Grid Lines

Now you’ll add a guide to each value. Inside the ForEach loop, in front of the existing Path, add:

Path { path in
  path.move(to: .zero)
  path.addLine(to: .init(x: 0, y: -graphSize))
}
.stroke(.gray, lineWidth: 1)
.offset(x: xCenter, y: yCenter)
.rotationEffect(.degrees(72.0 * Double(index)))

This code is identical to the previous code, except it doesn’t scale the height of the line so that it’s the entire length. You also stroke the line in gray and one point wide. Since it occurs before plotting the value, the plotted value will overlay it.

Add the following code after the assignment of yCenter and before the current ForEach:

// 1
let chartFraction = Array(stride(
  from: 0.2,
  to: 1.0,
  by: 0.2
))
ForEach(chartFraction, id: \.self) { fraction in
  // 2
  Path { path in
    path.addArc(
      center: .zero,
      radius: graphSize * fraction,
      startAngle: .degrees(0),
      endAngle: .degrees(360),
      clockwise: true
    )
  }
  // 3
  .stroke(.gray, lineWidth: 1)
  .offset(x: xCenter, y: yCenter)
}

This code produces grid lines for the chart that help the reader interpret the values.

Here’s what the lines do:

  1. You loop through a set of fractions that evenly divide the chart into five sections. SwiftUI will pass the value to the closure as fraction.
  2. For each value, you create a path and add an arc to the path. This arc will sweep around the center of the view with a radius of graphSize multiplied by the current fraction. You turn the arc into a circle by setting the start and end angles to sweep the full 360 degrees. This loop will draw a series of larger arcs as SwiftUI iterates over the values.
  3. You stroke each path as a gray line with a width of one point. As before, you use offset(x:y:) to set the center point to the center of the view.

Look at your chart in the preview. Adding the grid lines makes it easier to interpret each value.

img

In the next section, you’ll add a bit of color to the graph and add it to the app.

Coloring the Radar Chart

The chart looks a little dull in shades of gray. To add some color, add the following code before the body of the view:

let lineColors: [Color] = [.black, .red, .blue, .green, .yellow]

This constant defines a set of colors in an array. If you take them in the same order as the values in the chart, you’ll notice the colors relate to the measurements: black for the time, red for temperature, blue for the amount of water, green for the amount of tea and yellow for the rating.

With this array, you can add color to the chart’s values. Look for the line that reads .stroke(.black, lineWidth: 2) under comment five and change it to:

.stroke(lineColors[index], lineWidth: 2)

Now you draw each line in a unique color. To finish the radar chart, you’ll draw the polygon connecting the ends of each measurement line. Since this view is a bit more complicated, add the following code to the end of the current file:

struct PolygonChartView: View {
  var values: [Double]
  var graphSize: Double
  var colorArray: [Color]
  var xCenter: Double
  var yCenter: Double

  var body: some View {
    Path { path in
    }
  }
}

You created a new view that will encapsulate the polygon part of the view. Separating this into a separate view will improve readability while reducing clutter and problems with the SwiftUI compiler.

Now add the following new code to PolygonChartView after the properties:

var gradientColors: AngularGradient {
  AngularGradient(
    colors: colorArray + [colorArray.first ?? .black],
    center: .center,
    angle: .degrees(-90)
  )
}

You create an AngularGradient and pass your colorArray while appending the first color to its end. You do this to match the start and end colors of the angular gradient. Since the gradient starts toward the right, you set the angle property to -90 degrees to rotate the gradient by a one-quarter revolution so it starts upward.

Now fill in the Path closure in the view’s body with the following code:

// 1
for index in values.indices {
  let value = values[index]
  // 2
  let radians = Angle(degrees: 72.0 * Double(index)).radians
  // 3
  let x = sin(radians) * graphSize * value
  let y = cos(radians) * -graphSize * value
  // 4
  if index == 0 {
    path.move(to: .init(x: x, y: y))
  } else {
    path.addLine(to: .init(x: x, y: y))
  }
}
// 5
path.closeSubpath()

Here’s how this code works:

  1. Since you’re inside a Path closure, you use the standard Swift for in loop instead of ForEach. You then get the value for the current iteration.
  2. When plotting the values, you determine the angle of this measurement. You use the same 72 degrees angle and convert it to radians as you did before, since both sin and cos expect radian values.
  3. Earlier, you let SwiftUI rotate the lines, but you need to do it yourself in this view. To calculate the x and y values for a point of a specific length at a specified angle, you multiply the sine of the angle by the distance to calculate x. You multiply the cosine of the angle by the length to calculate y. You use a negative value for ybecause trigonometric functions assume y increases upward and in a counter-clockwise direction while in SwiftUI angles increase clockwise and y increases going down the view.
  4. The first time through the loop, you need to use move(to:) to start the path. For the remainder of the shape, you call addLine(to:) to add the new point with a line back to the previous point.
  5. To finalize the path, you call closeSubpath() on the Path. This method draws a line back to the start of the path to close the polygon.

Now you’ll let SwiftUI handle the offset and apply the gradient. Add the following code as modifiers to the Path you just added:

.offset(x: xCenter, y: yCenter)
.fill(gradientColors)
.opacity(0.5)

The first modifier offsets the Path, so the center lies at the center of the view. You then apply the gradient and reduce the opacity, so the gradient colors don’t overwhelm the chart.

Finally, use PolygonChartView in AnimatedRadarChart by adding this piece of code at the end of the GeometryReader:

PolygonChartView(
  values: values,
  graphSize: graphSize,
  colorArray: lineColors,
  xCenter: xCenter,
  yCenter: yCenter
)

Your preview will now show the final chart using the sample data.

img

Now it’s time to integrate the new chart into your app.

Using the Radar Chart

Open TeaRatingsView.swift. Now add the following code at the end of the ZStack in place of the comment reading // Add Radar Chart Here:

AnimatedRadarChart(
  time: Double(ratings[selectedRating].time),
  temperature: Double(ratings[selectedRating].temperature),
  amountWater: ratings[selectedRating].amountWater,
  amountTea: ratings[selectedRating].amountTea,
  rating: Double(ratings[selectedRating].rating)
)
.aspectRatio(contentMode: .fit)
.animation(.linear, value: selectedRating)
.padding(20)
.background(
  RoundedRectangle(cornerRadius: 20)
    .fill(Color("QuarterSpanishWhite"))
)

You add the radar chart below the swipeable area. It’ll display the chart for the current rating through the selectedRating index.

Run the app and select either Green Tea or Oolong Tea. Tap the Ratings area, and the app will show the radar chart. Change between them by swiping or tapping the indicator squares, and you’ll see the chart changes to match.

img

You might’ve noticed that despite the implicit animation applied when selectedRatingchanges, there’s no animation. In the next section, you’ll learn how to animate a view with multiple properties using AnimatablePair.

Animating the Radar Chart

When you need to animate a single value, you conform to the Animatable protocol. This protocol uses a property named animatableData that SwiftUI uses to pass the changing value into your view. In Chapter 6: Intro to Custom Animations, you set animatableData to a Double. So how can you manage five Doubles?

SwiftUI provides AnimatablePair especially for these cases. As the name implies, it supports two values instead of a single value. For example, the following code would expect two animated values for a view:

AnimatablePair<Double, Double>

So how would you handle the five values needed in this view? By nesting AnimatablePairs. Update the definition of AnimatedRadarChart to:

struct AnimatedRadarChart: View, Animatable {

Now add the following code after the properties for the view:

// 1
var animatableData: AnimatablePair<
  // 2
  AnimatablePair<Double, Double>,
  // 3
  AnimatablePair<
    // 4
    AnimatablePair<Double, Double>,
    // 5
    Double
  >
>

This code looks more complicated than it actually is. Here’s how this block of code lets SwiftUI pass in animated values:

  1. You begin by defining the animatableData property required by the Animatable protocol as a type of AnimatablePair. You then specify two values for the pair.
  2. For the first element of the pair, you define another AnimatablePair, which takes two Double values. Each AnimatablePair has two properties named first and second used to access their elements. This means that animatableData.first now consists of an AnimatablePair with elements you can access by `animatableData.first.firstandanimatableData.first.second.
  3. For the second element of the top level AnimatablePair, which you access through animatableData.second, you define another AnimatablePair.
  4. The first element of this new AnimatablePair consists of another AnimatablePair of two Doubles as the first element.
  5. The second element of that last AnimatablePair is a Double. As you can see, this gives you a total of the five Double values that you need.

This diagram shows how the elements flow from each other and how to access each element. You can continue this pattern if needed, but as you can see, it’s pretty complicated at five values.

img

Next you’ll need to assign each of the values in the nested AnimatablePairs to a property in the view. You want the first property in the view to match the first value in animatableData.

Here’s a diagram showing how the design ties to specific values:

img

The getter and setter for the property then need to translate between these elements of the nested structures and the properties on the view. Add the following code directly after the declaration of animatableData. The first line replaces the lone closing >symbol in the last code block:

> {
  get {
    // 1
    AnimatablePair(
      AnimatablePair(time, temperature),
      AnimatablePair(
        AnimatablePair(amountWater, amountTea),
        rating
      )
    )
  }
  set {
    // 2
    time = newValue.first.first
    temperature = newValue.first.second
    amountWater = newValue.second.first.first
    amountTea = newValue.second.first.second
    rating = newValue.second.second
  }
}

Follow the diagram, and you’ll see how the data structure for the AnimatablePairmaps to the properties and the structure’s properties. Here are the specifics for the two methods:

  1. The getter for the property needs to return a value matching the complicated structure you defined earlier. You create a series of nested AnimatablePairtypes with the values set as shown in the diagram.
  2. Setting the view’s properties from the AnimatablePair requires you to navigate the first and second properties.

While complicated, this code wraps up everything needed to animate the view. Run the app and select either Green Tea or Oolong Tea. Tap the Ratings area, and the radar chart shows each rating when selected. As you change between the ratings, you’ll see the view now animates between the charts.

img

Key Points

  • Transitions are a type of animation. When combining transitions, you’ll find it easier to use different state changes to control each individually.
  • You can apply an animation to a transition that will set the transition’s animation curve and duration.
  • A radar chart provides a way to visualize the relationship between multiple related values.
  • You can use the AnimatablePair type when you need to animate multiple values in a view that conforms to the Animatable protocol.
  • If you need to animate more than two values, you can nest multiple AnimatablePair structures within each other. While this can quickly become complicated, it’ll let you support many values.

Where to Go From Here?